diff --git a/swh/web/assets/config/.eslintrc b/swh/web/assets/config/.eslintrc index 6995c530c..69e3dadef 100644 --- a/swh/web/assets/config/.eslintrc +++ b/swh/web/assets/config/.eslintrc @@ -1,307 +1,307 @@ { "parser": "babel-eslint", "parserOptions": { "ecmaVersion": 2017, "ecmaFeatures": { "experimentalObjectRestSpread": true, "jsx": true }, "sourceType": "module", "allowImportExportEverywhere": true }, "env": { "es6": true, "node": true }, "plugins": [ "import", "node", "promise", "standard" ], "globals": { "document": false, "navigator": false, "window": false, "$": false, "jQuery": false, "history": false, "localStorage": false, "sessionStorage": false, "Urls": false, "hljs": false, "Waypoint": false, "swh": false, "fetch": false, "__STATIC__": false, "Image": false, "Cookies": false, "grecaptcha": false }, "rules": { "accessor-pairs": "error", "arrow-spacing": ["error", { "before": true, "after": true }], "block-spacing": ["error", "always"], "brace-style": ["error", "1tbs", { "allowSingleLine": true }], "camelcase": ["error", { "properties": "never" }], "comma-dangle": ["error", { "arrays": "never", "objects": "never", "imports": "never", "exports": "never", "functions": "never" }], "comma-spacing": ["error", { "before": false, "after": true }], "comma-style": ["error", "last"], "constructor-super": "error", "curly": ["error", "multi-line"], "dot-location": ["error", "property"], "eol-last": "error", "eqeqeq": ["error", "always", { "null": "ignore" }], "func-call-spacing": ["error", "never"], "generator-star-spacing": ["error", { "before": true, "after": true }], "handle-callback-err": ["error", "^(err|error)$"], "indent": ["error", 2, { "SwitchCase": 1, "VariableDeclarator": 1, "outerIIFEBody": 1, "MemberExpression": 1, "FunctionDeclaration": { - "parameters": 1, + "parameters": "first", "body": 1 }, "FunctionExpression": { - "parameters": 1, + "parameters": "first", "body": 1 }, "CallExpression": { - "arguments": 1 + "arguments": "first" }, "ArrayExpression": 1, "ObjectExpression": 1, "ImportDeclaration": 1, "flatTernaryExpressions": false, "ignoreComments": false }], "key-spacing": ["error", { "beforeColon": false, "afterColon": true }], "keyword-spacing": ["error", { "before": true, "after": true }], "new-cap": ["error", { "newIsCap": true, "capIsNew": false }], "new-parens": "error", "no-array-constructor": "error", "no-caller": "error", "no-class-assign": "error", "no-compare-neg-zero": "error", "no-cond-assign": "error", "no-const-assign": "error", "no-constant-condition": ["error", { "checkLoops": false }], "no-control-regex": "error", "no-debugger": "error", "no-delete-var": "error", "no-dupe-args": "error", "no-dupe-class-members": "error", "no-dupe-keys": "error", "no-duplicate-case": "error", "no-empty-character-class": "error", "no-empty-pattern": "error", "no-eval": "error", "no-ex-assign": "error", "no-extend-native": "error", "no-extra-bind": "error", "no-extra-boolean-cast": "error", "no-extra-parens": ["error", "functions"], "no-fallthrough": "error", "no-floating-decimal": "error", "no-func-assign": "error", "no-global-assign": "error", "no-implied-eval": "error", "no-inner-declarations": ["error", "functions"], "no-invalid-regexp": "error", "no-irregular-whitespace": "error", "no-iterator": "error", "no-label-var": "error", "no-labels": ["error", { "allowLoop": false, "allowSwitch": false }], "no-lone-blocks": "error", "no-mixed-operators": ["error", { "groups": [ ["==", "!=", "===", "!==", ">", ">=", "<", "<="], ["&&", "||"], ["in", "instanceof"] ], "allowSamePrecedence": true }], "no-mixed-spaces-and-tabs": "error", "no-multi-spaces": "error", "no-multi-str": "error", "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }], "no-negated-in-lhs": "error", "no-new": 0, "no-new-func": "error", "no-new-object": "error", "no-new-require": "error", "no-new-symbol": "error", "no-new-wrappers": "error", "no-obj-calls": "error", "no-octal": "error", "no-octal-escape": "error", "no-path-concat": "error", "no-proto": "error", "no-redeclare": "error", "no-regex-spaces": "error", "no-return-assign": ["error", "except-parens"], "no-return-await": "error", "no-self-assign": "error", "no-self-compare": "error", "no-sequences": "error", "no-shadow-restricted-names": "error", "no-sparse-arrays": "error", "no-tabs": "error", "no-template-curly-in-string": "error", "no-this-before-super": "error", "no-throw-literal": "error", "no-trailing-spaces": "error", "no-undef": "error", "no-undef-init": "error", "no-unexpected-multiline": "error", "no-unmodified-loop-condition": "error", "no-unneeded-ternary": ["error", { "defaultAssignment": false }], "no-unreachable": "error", "no-unsafe-finally": "error", "no-unsafe-negation": "error", "no-unused-expressions": ["error", { "allowShortCircuit": true, "allowTernary": true, "allowTaggedTemplates": true }], "no-unused-vars": ["error", { "vars": "all", "args": "none", "ignoreRestSiblings": true }], "no-use-before-define": ["error", { "functions": false, "classes": false, "variables": false }], "no-useless-call": "error", "no-useless-computed-key": "error", "no-useless-constructor": "error", "no-useless-escape": "error", "no-useless-rename": "error", "no-useless-return": "error", "no-whitespace-before-property": "error", "no-with": "error", "object-property-newline": ["error", { "allowMultiplePropertiesPerLine": true }], "one-var": ["error", { "initialized": "never" }], "operator-linebreak": ["error", "after", { "overrides": { "?": "before", ":": "before" } }], "padded-blocks": ["off", { "blocks": "never", "switches": "never", "classes": "never" }], "prefer-promise-reject-errors": "error", "quotes": ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": true }], "rest-spread-spacing": ["error", "never"], "semi": ["error", "always"], "semi-spacing": ["error", { "before": false, "after": true }], "space-before-blocks": ["error", "always"], "space-before-function-paren": ["error", "never"], "space-in-parens": ["error", "never"], "space-infix-ops": "error", "space-unary-ops": ["error", { "words": true, "nonwords": false }], "spaced-comment": ["error", "always", { "line": { "markers": ["*package", "!", "/", ",", "="] }, "block": { "balanced": true, "markers": ["*package", "!", ",", ":", "::", "flow-include"], "exceptions": ["*"] } }], "symbol-description": "error", "template-curly-spacing": ["error", "never"], "template-tag-spacing": ["error", "never"], "unicode-bom": ["error", "never"], "use-isnan": "error", "valid-typeof": ["error", { "requireStringLiterals": true }], "wrap-iife": ["error", "any", { "functionPrototypeMethods": true }], "yield-star-spacing": ["error", "both"], "yoda": ["error", "never"], "import/export": "error", "import/first": "error", "import/no-duplicates": "error", "import/no-webpack-loader-syntax": "off", "node/no-deprecated-api": "error", "node/process-exit-as-throw": "error", "promise/param-names": "error", "standard/array-bracket-even-spacing": ["error", "either"], "standard/computed-property-even-spacing": ["error", "even"], "standard/no-callback-literal": "error", "standard/object-curly-even-spacing": ["error", "either"] } } \ No newline at end of file diff --git a/swh/web/assets/src/bundles/admin/origin-save.js b/swh/web/assets/src/bundles/admin/origin-save.js index e4878f0a7..0f876b4df 100644 --- a/swh/web/assets/src/bundles/admin/origin-save.js +++ b/swh/web/assets/src/bundles/admin/origin-save.js @@ -1,252 +1,256 @@ /** * Copyright (C) 2018 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {handleFetchError, csrfPost} from 'utils/functions'; let authorizedOriginTable; let unauthorizedOriginTable; let pendingSaveRequestsTable; let acceptedSaveRequestsTable; let rejectedSaveRequestsTable; function enableRowSelection(tableSel) { $(`${tableSel} tbody`).on('click', 'tr', function() { if ($(this).hasClass('selected')) { $(this).removeClass('selected'); } else { $(`${tableSel} tr.selected`).removeClass('selected'); $(this).addClass('selected'); } }); } export function initOriginSaveAdmin() { $(document).ready(() => { $.fn.dataTable.ext.errMode = 'throw'; authorizedOriginTable = $('#swh-authorized-origin-urls').DataTable({ serverSide: true, ajax: Urls.admin_origin_save_authorized_urls_list(), columns: [{data: 'url', name: 'url'}], scrollY: '50vh', scrollCollapse: true, info: false }); enableRowSelection('#swh-authorized-origin-urls'); unauthorizedOriginTable = $('#swh-unauthorized-origin-urls').DataTable({ serverSide: true, ajax: Urls.admin_origin_save_unauthorized_urls_list(), columns: [{data: 'url', name: 'url'}], scrollY: '50vh', scrollCollapse: true, info: false }); enableRowSelection('#swh-unauthorized-origin-urls'); let columnsData = [ { data: 'save_request_date', name: 'request_date', render: (data, type, row) => { if (type === 'display') { let date = new Date(data); return date.toLocaleString(); } return data; } }, { data: 'origin_type', name: 'origin_type' }, { data: 'origin_url', name: 'origin_url', render: (data, type, row) => { if (type === 'display') { return `${data}`; } return data; } } ]; pendingSaveRequestsTable = $('#swh-origin-save-pending-requests').DataTable({ serverSide: true, ajax: Urls.browse_origin_save_requests_list('pending'), columns: columnsData, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']] }); enableRowSelection('#swh-origin-save-pending-requests'); rejectedSaveRequestsTable = $('#swh-origin-save-rejected-requests').DataTable({ serverSide: true, ajax: Urls.browse_origin_save_requests_list('rejected'), columns: columnsData, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']] }); columnsData.push({ data: 'save_task_status', name: 'save_task_status', render: (data, type, row) => { if (data === 'succeed') { let browseOriginUrl = Urls.browse_origin(row.origin_url); return `${data}`; } return data; } }); acceptedSaveRequestsTable = $('#swh-origin-save-accepted-requests').DataTable({ serverSide: true, ajax: Urls.browse_origin_save_requests_list('accepted'), columns: columnsData, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']] }); $('#swh-origin-save-requests-nav-item').on('shown.bs.tab', () => { pendingSaveRequestsTable.draw(); }); $('#swh-origin-save-url-filters-nav-item').on('shown.bs.tab', () => { authorizedOriginTable.draw(); }); $('#swh-authorized-origins-tab').on('shown.bs.tab', () => { authorizedOriginTable.draw(); }); $('#swh-unauthorized-origins-tab').on('shown.bs.tab', () => { unauthorizedOriginTable.draw(); }); $('#swh-save-requests-pending-tab').on('shown.bs.tab', () => { pendingSaveRequestsTable.draw(); }); $('#swh-save-requests-accepted-tab').on('shown.bs.tab', () => { acceptedSaveRequestsTable.draw(); }); $('#swh-save-requests-rejected-tab').on('shown.bs.tab', () => { rejectedSaveRequestsTable.draw(); }); $('#swh-save-requests-pending-tab').click(() => { pendingSaveRequestsTable.ajax.reload(null, false); }); $('#swh-save-requests-accepted-tab').click(() => { acceptedSaveRequestsTable.ajax.reload(null, false); }); $('#swh-save-requests-rejected-tab').click(() => { rejectedSaveRequestsTable.ajax.reload(null, false); }); }); } export function addAuthorizedOriginUrl() { let originUrl = $('#swh-authorized-url-prefix').val(); let addOriginUrl = Urls.admin_origin_save_add_authorized_url(originUrl); csrfPost(addOriginUrl) .then(handleFetchError) .then(() => { authorizedOriginTable.row.add({'url': originUrl}).draw(); }) .catch(response => { - swh.webapp.showModalMessage('Duplicated origin url prefix', + swh.webapp.showModalMessage( + 'Duplicated origin url prefix', 'The provided origin url prefix is already registered in the authorized list.'); }); } export function removeAuthorizedOriginUrl() { let originUrl = $('#swh-authorized-origin-urls tr.selected').text(); if (originUrl) { let removeOriginUrl = Urls.admin_origin_save_remove_authorized_url(originUrl); csrfPost(removeOriginUrl) .then(handleFetchError) .then(() => { authorizedOriginTable.row('.selected').remove().draw(); }) .catch(() => {}); } } export function addUnauthorizedOriginUrl() { let originUrl = $('#swh-unauthorized-url-prefix').val(); let addOriginUrl = Urls.admin_origin_save_add_unauthorized_url(originUrl); csrfPost(addOriginUrl) .then(handleFetchError) .then(() => { unauthorizedOriginTable.row.add({'url': originUrl}).draw(); }) .catch(() => { - swh.webapp.showModalMessage('Duplicated origin url prefix', + swh.webapp.showModalMessage( + 'Duplicated origin url prefix', 'The provided origin url prefix is already registered in the unauthorized list.'); }); } export function removeUnauthorizedOriginUrl() { let originUrl = $('#swh-unauthorized-origin-urls tr.selected').text(); if (originUrl) { let removeOriginUrl = Urls.admin_origin_save_remove_unauthorized_url(originUrl); csrfPost(removeOriginUrl) .then(handleFetchError) .then(() => { unauthorizedOriginTable.row('.selected').remove().draw(); }) .catch(() => {}); } } export function acceptOriginSaveRequest() { let selectedRow = pendingSaveRequestsTable.row('.selected'); if (selectedRow.length) { let acceptOriginSaveRequestCallback = () => { let rowData = selectedRow.data(); let acceptSaveRequestUrl = Urls.admin_origin_save_request_accept(rowData['origin_type'], rowData['origin_url']); csrfPost(acceptSaveRequestUrl) .then(() => { pendingSaveRequestsTable.ajax.reload(null, false); }); }; - swh.webapp.showModalConfirm('Accept origin save request ?', + swh.webapp.showModalConfirm( + 'Accept origin save request ?', 'Are you sure to accept this origin save request ?', acceptOriginSaveRequestCallback); } } export function rejectOriginSaveRequest() { let selectedRow = pendingSaveRequestsTable.row('.selected'); if (selectedRow.length) { let rejectOriginSaveRequestCallback = () => { let rowData = selectedRow.data(); let rejectSaveRequestUrl = Urls.admin_origin_save_request_reject(rowData['origin_type'], rowData['origin_url']); csrfPost(rejectSaveRequestUrl) .then(() => { pendingSaveRequestsTable.ajax.reload(null, false); }); }; - swh.webapp.showModalConfirm('Reject origin save request ?', + swh.webapp.showModalConfirm( + 'Reject origin save request ?', 'Are you sure to reject this origin save request ?', rejectOriginSaveRequestCallback); } } diff --git a/swh/web/assets/src/bundles/browse/origin-search.js b/swh/web/assets/src/bundles/browse/origin-search.js index f6cfbeb34..ebd758ea1 100644 --- a/swh/web/assets/src/bundles/browse/origin-search.js +++ b/swh/web/assets/src/bundles/browse/origin-search.js @@ -1,258 +1,260 @@ /** * Copyright (C) 2018 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {heapsPermute} from 'utils/heaps-permute'; import {handleFetchError} from 'utils/functions'; let originPatterns; let perPage = 100; let limit = perPage * 2; let offset = 0; let currentData = null; let inSearch = false; function fixTableRowsStyle() { setTimeout(() => { $('#origin-search-results tbody tr').removeAttr('style'); }); } function clearOriginSearchResultsTable() { $('#origin-search-results tbody tr').remove(); } function populateOriginSearchResultsTable(data, offset) { let localOffset = offset % limit; if (data.length > 0) { $('#swh-origin-search-results').show(); $('#swh-no-result').hide(); clearOriginSearchResultsTable(); let table = $('#origin-search-results tbody'); for (let i = localOffset; i < localOffset + perPage && i < data.length; ++i) { let elem = data[i]; let browseUrl = Urls.browse_origin(elem.url); let tableRow = ``; tableRow += `${elem.type}`; tableRow += `${elem.url}`; tableRow += ``; tableRow += ''; table.append(tableRow); // get async latest visit snapshot and update visit status icon let latestSnapshotUrl = Urls.browse_origin_latest_snapshot(elem.id); fetch(latestSnapshotUrl) .then(response => response.json()) .then(data => { let originId = elem.id; $(`#visit-status-origin-${originId}`).children().remove(); if (data) { $(`#visit-status-origin-${originId}`).append(''); } else { $(`#visit-status-origin-${originId}`).append(''); if ($('#swh-filter-empty-visits').prop('checked')) { $(`#origin-${originId}`).remove(); } } }); } fixTableRowsStyle(); swh.webapp.initTableRowLinks('tr.swh-search-result-entry'); } else { $('#swh-origin-search-results').hide(); $('#swh-no-result').text('No origins matching the search criteria were found.'); $('#swh-no-result').show(); } if (data.length - localOffset < perPage || (data.length < limit && (localOffset + perPage) === data.length)) { $('#origins-next-results-button').addClass('disabled'); } else { $('#origins-next-results-button').removeClass('disabled'); } if (offset > 0) { $('#origins-prev-results-button').removeClass('disabled'); } else { $('#origins-prev-results-button').addClass('disabled'); } inSearch = false; if (typeof Storage !== 'undefined') { sessionStorage.setItem('last-swh-origin-search-offset', offset); } setTimeout(() => { window.scrollTo(0, 0); }); } function escapeStringRegexp(str) { let matchOperatorsRe = /[|\\{}()[\]^$+*?.]/g; return str.replace(matchOperatorsRe, '\\\\\\$&'); } function searchOrigins(patterns, limit, searchOffset, offset) { originPatterns = patterns; let patternsArray = patterns.trim().replace(/\s+/g, ' ').split(' '); for (let i = 0; i < patternsArray.length; ++i) { patternsArray[i] = escapeStringRegexp(patternsArray[i]); } let patternsPermut = []; heapsPermute(patternsArray, p => patternsPermut.push(p.join('.*'))); let regex = patternsPermut.join('|'); let withVisit = $('#swh-search-origins-with-visit').prop('checked'); let searchUrl = Urls.browse_origin_search(regex) + `?limit=${limit}&offset=${searchOffset}®exp=true&with_visit=${withVisit}`; clearOriginSearchResultsTable(); $('.swh-loading').addClass('show'); fetch(searchUrl) .then(handleFetchError) .then(response => response.json()) .then(data => { currentData = data; if (typeof Storage !== 'undefined') { sessionStorage.setItem('last-swh-origin-url-patterns', patterns); sessionStorage.setItem('last-swh-origin-search-results', JSON.stringify(data)); sessionStorage.setItem('last-swh-origin-search-offset', offset); } $('.swh-loading').removeClass('show'); populateOriginSearchResultsTable(data, offset); }) .catch(response => { $('.swh-loading').removeClass('show'); inSearch = false; $('#swh-origin-search-results').hide(); $('#swh-no-result').text(`Error ${response.status}: ${response.statusText}`); $('#swh-no-result').show(); }); } function doSearch() { $('#swh-no-result').hide(); let patterns = $('#origins-url-patterns').val(); offset = 0; inSearch = true; // first try to resolve a swh persistent identifier let resolvePidUrl = Urls.api_resolve_swh_pid(patterns); fetch(resolvePidUrl) .then(handleFetchError) .then(response => response.json()) .then(data => { // pid has been successfully resolved, // so redirect to browse page window.location = data.browse_url; }) .catch(response => { // pid resolving failed if (patterns.startsWith('swh:')) { // display a useful error message if the input // looks like a swh pid response.json().then(data => { $('#swh-origin-search-results').hide(); $('.swh-search-pagination').hide(); $('#swh-no-result').text(data.reason); $('#swh-no-result').show(); }); } else { // otherwise, proceed with origins search $('#swh-origin-search-results').show(); $('.swh-search-pagination').show(); searchOrigins(patterns, limit, offset, offset); } }); } export function initOriginSearch() { $(document).ready(() => { if (typeof Storage !== 'undefined') { originPatterns = sessionStorage.getItem('last-swh-origin-url-patterns'); let data = sessionStorage.getItem('last-swh-origin-search-results'); offset = sessionStorage.getItem('last-swh-origin-search-offset'); if (data) { $('#origins-url-patterns').val(originPatterns); offset = parseInt(offset); currentData = JSON.parse(data); populateOriginSearchResultsTable(currentData, offset); } let withVisit = sessionStorage.getItem('last-swh-origin-with-visit'); if (withVisit !== null) { $('#swh-search-origins-with-visit').prop('checked', JSON.parse(withVisit)); } let filterEmptyVisits = sessionStorage.getItem('last-filter-empty-visits'); if (filterEmptyVisits !== null) { $('#swh-filter-empty-visits').prop('checked', JSON.parse(filterEmptyVisits)); } } $('#swh-search-origins').submit(event => { event.preventDefault(); let patterns = $('#origins-url-patterns').val().trim(); if (typeof Storage !== 'undefined') { sessionStorage.setItem('last-swh-origin-url-patterns', patterns); sessionStorage.setItem('last-swh-origin-search-results', ''); sessionStorage.setItem('last-swh-origin-search-offset', ''); } let withVisit = $('#swh-search-origins-with-visit').prop('checked'); let queryParameters = '?q=' + encodeURIComponent(patterns); if (withVisit) { queryParameters += '&with_visit'; } // Update the url, triggering page reload and effective search window.location.search = queryParameters; }); $('#origins-next-results-button').click(event => { if ($('#origins-next-results-button').hasClass('disabled') || inSearch) { return; } inSearch = true; offset += perPage; if (!currentData || (offset >= limit && offset % limit === 0)) { searchOrigins(originPatterns, limit, offset, offset); } else { populateOriginSearchResultsTable(currentData, offset); } event.preventDefault(); }); $('#origins-prev-results-button').click(event => { if ($('#origins-prev-results-button').hasClass('disabled') || inSearch) { return; } inSearch = true; offset -= perPage; if (!currentData || (offset > 0 && (offset + perPage) % limit === 0)) { searchOrigins(originPatterns, limit, (offset + perPage) - limit, offset); } else { populateOriginSearchResultsTable(currentData, offset); } event.preventDefault(); }); $(document).on('shown.bs.tab', 'a[data-toggle="tab"]', e => { if (e.currentTarget.text.trim() === 'Search') { fixTableRowsStyle(); } }); $(window).on('unload', () => { if (typeof Storage !== 'undefined') { - sessionStorage.setItem('last-swh-origin-with-visit', + sessionStorage.setItem( + 'last-swh-origin-with-visit', JSON.stringify($('#swh-search-origins-with-visit').prop('checked'))); - sessionStorage.setItem('last-filter-empty-visits', + sessionStorage.setItem( + 'last-filter-empty-visits', JSON.stringify($('#swh-filter-empty-visits').prop('checked'))); } }); let urlParams = new URLSearchParams(window.location.search); let query = urlParams.get('q'); let withVisit = urlParams.has('with_visit'); let data = sessionStorage.getItem('last-swh-origin-search-results'); if (query && !data) { $('#origins-url-patterns').val(query); if (withVisit) { $('#swh-search-origins-with-visit').prop('checked', true); } doSearch(); } }); } diff --git a/swh/web/assets/src/bundles/origin/visits-histogram.js b/swh/web/assets/src/bundles/origin/visits-histogram.js index f0613810c..68799fa17 100644 --- a/swh/web/assets/src/bundles/origin/visits-histogram.js +++ b/swh/web/assets/src/bundles/origin/visits-histogram.js @@ -1,337 +1,337 @@ /** * Copyright (C) 2018 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ // Creation of a stacked histogram with D3.js for software origin visits history // Parameters description: // - container: selector for the div that will contain the histogram // - visitsData: raw swh origin visits data // - currentYear: the visits year to display by default // - yearClickCallback: callback when the user selects a year through the histogram import * as d3 from 'd3'; export function createVisitsHistogram(container, visitsData, currentYear, yearClickCallback) { // remove previously created histogram and tooltip if any d3.select(container).select('svg').remove(); d3.select('div.d3-tooltip').remove(); // histogram size and margins let width = 1000; let height = 300; let margin = {top: 20, right: 80, bottom: 30, left: 50}; // create responsive svg let svg = d3.select(container) .attr('style', - 'padding-bottom: ' + Math.ceil(height * 100 / width) + '%') + 'padding-bottom: ' + Math.ceil(height * 100 / width) + '%') .append('svg') .attr('viewBox', '0 0 ' + width + ' ' + height); // create tooltip div let tooltip = d3.select('body') .append('div') .attr('class', 'd3-tooltip') .style('opacity', 0); // update width and height without margins width = width - margin.left - margin.right; height = height - margin.top - margin.bottom; // create main svg group element let g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); // create x scale let x = d3.scaleTime().rangeRound([0, width]); // create y scale let y = d3.scaleLinear().range([height, 0]); // create ordinal colorscale mapping visit status let colors = d3.scaleOrdinal() .domain(['full', 'partial', 'failed', 'ongoing']) .range(['#008000', '#edc344', '#ff0000', '#0000ff']); // first swh crawls were made in 2015 let startYear = 2015; // set latest display year as the current one let now = new Date(); let endYear = now.getUTCFullYear() + 1; let monthExtent = [new Date(Date.UTC(startYear, 0, 1)), new Date(Date.UTC(endYear, 0, 1))]; // create months bins based on setup extent let monthBins = d3.timeMonths(d3.timeMonth.offset(monthExtent[0], -1), monthExtent[1]); // create years bins based on setup extent let yearBins = d3.timeYears(monthExtent[0], monthExtent[1]); // set x scale domain x.domain(d3.extent(monthBins)); // use D3 histogram layout to create a function that will bin the visits by month let binByMonth = d3.histogram() .value(d => d.date) .domain(x.domain()) .thresholds(monthBins); // use D3 nest function to group the visits by status let visitsByStatus = d3.nest() .key(d => d['status']) .sortKeys(d3.ascending) .entries(visitsData); // prepare data in order to be able to stack visit statuses by month let statuses = []; let histData = []; for (let i = 0; i < monthBins.length; ++i) { histData[i] = {}; } visitsByStatus.forEach(entry => { statuses.push(entry.key); let monthsData = binByMonth(entry.values); for (let i = 0; i < monthsData.length; ++i) { histData[i]['x0'] = monthsData[i]['x0']; histData[i]['x1'] = monthsData[i]['x1']; histData[i][entry.key] = monthsData[i]; } }); // create function to stack visits statuses by month let stacked = d3.stack() .keys(statuses) .value((d, key) => d[key].length); // compute the maximum amount of visits by month let yMax = d3.max(histData, d => { let total = 0; for (let i = 0; i < statuses.length; ++i) { total += d[statuses[i]].length; } return total; }); // set y scale domain y.domain([0, yMax]); // compute ticks values for the y axis let step = 5; let yTickValues = []; for (let i = 0; i <= yMax / step; ++i) { yTickValues.push(i * step); } if (yTickValues.length === 0) { for (let i = 0; i <= yMax; ++i) { yTickValues.push(i); } } else if (yMax % step !== 0) { yTickValues.push(yMax); } // add histogram background grid g.append('g') .attr('class', 'grid') .call(d3.axisLeft(y) .tickValues(yTickValues) .tickSize(-width) .tickFormat('')); // create one fill only rectangle by displayed year // each rectangle will be made visible when hovering the mouse over a year range // user will then be able to select a year by clicking in the rectangle g.append('g') .selectAll('rect') .data(yearBins) .enter().append('rect') .attr('class', d => 'year' + d.getUTCFullYear()) .attr('fill', 'red') .attr('fill-opacity', d => d.getUTCFullYear() === currentYear ? 0.3 : 0) .attr('stroke', 'none') .attr('x', d => { let date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return x(date); }) .attr('y', 0) .attr('height', height) .attr('width', d => { let date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); let yearWidth = x(d3.timeYear.offset(date, 1)) - x(date); return yearWidth; }) // mouse event callbacks used to show rectangle years // when hovering the mouse over the histograms .on('mouseover', d => { svg.selectAll('rect.year' + d.getUTCFullYear()) .attr('fill-opacity', 0.5); }) .on('mouseout', d => { svg.selectAll('rect.year' + d.getUTCFullYear()) .attr('fill-opacity', 0); svg.selectAll('rect.year' + currentYear) .attr('fill-opacity', 0.3); }) // callback to select a year after a mouse click // in a rectangle year .on('click', d => { svg.selectAll('rect.year' + currentYear) .attr('fill-opacity', 0); svg.selectAll('rect.yearoutline' + currentYear) .attr('stroke', 'none'); currentYear = d.getUTCFullYear(); svg.selectAll('rect.year' + currentYear) .attr('fill-opacity', 0.5); svg.selectAll('rect.yearoutline' + currentYear) .attr('stroke', 'black'); yearClickCallback(currentYear); }); // create the stacked histogram of visits g.append('g') .selectAll('g') .data(stacked(histData)) .enter().append('g') .attr('fill', d => colors(d.key)) .selectAll('rect') .data(d => d) .enter().append('rect') .attr('class', d => 'month' + d.data.x1.getMonth()) .attr('x', d => x(d.data.x0)) .attr('y', d => y(d[1])) .attr('height', d => y(d[0]) - y(d[1])) .attr('width', d => x(d.data.x1) - x(d.data.x0) - 1) // mouse event callbacks used to show rectangle years // but also to show tooltip when hovering the mouse // over the histogram bars .on('mouseover', d => { svg.selectAll('rect.year' + d.data.x1.getUTCFullYear()) .attr('fill-opacity', 0.5); tooltip.transition() .duration(200) .style('opacity', 1); let ds = d.data.x1.toISOString().substr(0, 7).split('-'); let tooltipText = '' + ds[1] + ' / ' + ds[0] + ':
'; for (let i = 0; i < statuses.length; ++i) { let visitStatus = statuses[i]; let nbVisits = d.data[visitStatus].length; if (nbVisits === 0) continue; tooltipText += nbVisits + ' ' + visitStatus + ' visits'; if (i !== statuses.length - 1) tooltipText += '
'; } tooltip.html(tooltipText) .style('left', d3.event.pageX + 15 + 'px') .style('top', d3.event.pageY + 'px'); }) .on('mouseout', d => { svg.selectAll('rect.year' + d.data.x1.getUTCFullYear()) .attr('fill-opacity', 0); svg.selectAll('rect.year' + currentYear) .attr('fill-opacity', 0.3); tooltip.transition() .duration(500) .style('opacity', 0); }) .on('mousemove', () => { tooltip.style('left', d3.event.pageX + 15 + 'px') .style('top', d3.event.pageY + 'px'); }) // callback to select a year after a mouse click // inside a histogram bar .on('click', d => { svg.selectAll('rect.year' + currentYear) .attr('fill-opacity', 0); svg.selectAll('rect.yearoutline' + currentYear) .attr('stroke', 'none'); currentYear = d.data.x1.getUTCFullYear(); svg.selectAll('rect.year' + currentYear) .attr('fill-opacity', 0.5); svg.selectAll('rect.yearoutline' + currentYear) .attr('stroke', 'black'); yearClickCallback(currentYear); }); // create one stroke only rectangle by displayed year // that will be displayed on top of the histogram when the user has selected a year g.append('g') .selectAll('rect') .data(yearBins) .enter().append('rect') .attr('class', d => 'yearoutline' + d.getUTCFullYear()) .attr('fill', 'none') .attr('stroke', d => d.getUTCFullYear() === currentYear ? 'black' : 'none') .attr('x', d => { let date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return x(date); }) .attr('y', 0) .attr('height', height) .attr('width', d => { let date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); let yearWidth = x(d3.timeYear.offset(date, 1)) - x(date); return yearWidth; }); // add x axis with a tick for every 1st day of each year let xAxis = g.append('g') .attr('class', 'axis') .attr('transform', 'translate(0,' + height + ')') .call( d3.axisBottom(x) .ticks(d3.timeYear.every(1)) .tickFormat(d => d.getUTCFullYear()) ); // shift tick labels in order to display them at the middle // of each year range xAxis.selectAll('text') .attr('transform', d => { let year = d.getUTCFullYear(); let date = new Date(Date.UTC(year, 0, 1)); let yearWidth = x(d3.timeYear.offset(date, 1)) - x(date); return 'translate(' + -yearWidth / 2 + ', 0)'; }); // add y axis for the number of visits g.append('g') .attr('class', 'axis') .call(d3.axisLeft(y).tickValues(yTickValues)); // add legend for visit statuses let legendGroup = g.append('g') .attr('font-family', 'sans-serif') .attr('font-size', 10) .attr('text-anchor', 'end'); legendGroup.append('text') .attr('x', width + margin.right - 5) .attr('y', 9.5) .attr('dy', '0.32em') .text('visit status:'); let legend = legendGroup.selectAll('g') .data(statuses.slice().reverse()) .enter().append('g') .attr('transform', (d, i) => 'translate(0,' + (i + 1) * 20 + ')'); legend.append('rect') .attr('x', width + 2 * margin.right / 3) .attr('width', 19) .attr('height', 19) .attr('fill', colors); legend.append('text') .attr('x', width + 2 * margin.right / 3 - 5) .attr('y', 9.5) .attr('dy', '0.32em') .text(d => d); // add text label for the y axis g.append('text') .attr('transform', 'rotate(-90)') .attr('y', -margin.left) .attr('x', -(height / 2)) .attr('dy', '1em') .style('text-anchor', 'middle') .text('Number of visits'); } diff --git a/swh/web/assets/src/bundles/revision/diff-utils.js b/swh/web/assets/src/bundles/revision/diff-utils.js index 39f2a9647..bd3a093b9 100644 --- a/swh/web/assets/src/bundles/revision/diff-utils.js +++ b/swh/web/assets/src/bundles/revision/diff-utils.js @@ -1,515 +1,518 @@ /** * Copyright (C) 2018 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import 'waypoints/lib/jquery.waypoints'; import {staticAsset} from 'utils/functions'; // path to static spinner asset let swhSpinnerSrc = staticAsset('img/swh-spinner.gif'); // number of changed files in the revision let changes = null; let nbChangedFiles = 0; // to track the number of already computed files diffs let nbDiffsComputed = 0; // the no newline at end of file marker from Github let noNewLineMarker = ` `; // to track the total number of added lines in files diffs let nbAdditions = 0; // to track the total number of deleted lines in files diffs let nbDeletions = 0; // to track the already computed diffs by id let computedDiffs = {}; // map a diff id to its computation url let diffsUrls = {}; // to check if a DOM element is in the viewport function isInViewport(elt) { let elementTop = $(elt).offset().top; let elementBottom = elementTop + $(elt).outerHeight(); let viewportTop = $(window).scrollTop(); let viewportBottom = viewportTop + $(window).height(); return elementBottom > viewportTop && elementTop < viewportBottom; } // to format the diffs line numbers function formatDiffLineNumbers(fromLine, toLine, maxNumberChars) { let ret = ''; if (fromLine != null) { for (let i = 0; i < (maxNumberChars - fromLine.length); ++i) { ret += ' '; } ret += fromLine; } if (fromLine != null && toLine != null) { ret += ' '; } if (toLine != null) { for (let i = 0; i < (maxNumberChars - toLine.length); ++i) { ret += ' '; } ret += toLine; } return ret; } // to compute diff and process it for display export function computeDiff(diffUrl, diffId) { // force diff computation ? let force = diffUrl.indexOf('force=true') !== -1; // it no forced computation and diff already computed, do nothing if (!force && computedDiffs.hasOwnProperty(diffId)) { return; } // mark diff computation as already requested computedDiffs[diffId] = true; $(`#${diffId}-loading`).css('visibility', 'visible'); // set spinner visible while requesting diff $(`#${diffId}-loading`).css('display', 'block'); $(`#${diffId}-highlightjs`).css('display', 'none'); // request diff computation and process it fetch(diffUrl) .then(response => response.json()) .then(data => { // increment number of computed diffs ++nbDiffsComputed; // toggle the 'Compute all diffs' button if all diffs have been computed if (nbDiffsComputed === changes.length) { $('#swh-compute-all-diffs').addClass('active'); } // Large diff (> threshold) are not automatically computed, // add a button to force its computation if (data.diff_str.indexOf('Large diff') === 0) { $(`#${diffId}`)[0].innerHTML = data.diff_str + `
`; setDiffVisible(diffId); } else if (data.diff_str.indexOf('@@') !== 0) { $(`#${diffId}`).text(data.diff_str); setDiffVisible(diffId); } else { // prepare code highlighting $(`.${diffId}`).removeClass('nohighlight'); $(`.${diffId}`).addClass(data.language); // set unified diff text $(`#${diffId}`).text(data.diff_str); // code highlighting for unified diff $(`#${diffId}`).each((i, block) => { hljs.highlightBlock(block); hljs.lineNumbersBlock(block); }); // hljs.lineNumbersBlock is asynchronous so we have to postpone our // next treatments by adding it at the end of the current js events queue setTimeout(() => { // process unified diff lines in order to generate side-by-side diffs text // but also compute line numbers for unified and side-by-side diffs let linesInfoRegExp = new RegExp(/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@$/gm); let baseFromLine = ''; let baseToLine = ''; let fromToLines = []; let fromLines = []; let toLines = []; let maxNumberChars = 0; let diffFromStr = ''; let diffToStr = ''; let linesOffset = 0; $(`#${diffId} .hljs-ln-numbers`).each((i, lnElt) => { let lnText = lnElt.nextSibling.innerText; let linesInfo = linesInfoRegExp.exec(lnText); let fromLine = ''; let toLine = ''; // parsed lines info from the diff output if (linesInfo) { baseFromLine = parseInt(linesInfo[1]) - 1; baseToLine = parseInt(linesInfo[3]) - 1; linesOffset = 0; diffFromStr += (lnText + '\n'); diffToStr += (lnText + '\n'); fromLines.push(''); toLines.push(''); // line removed in the from file } else if (lnText.length > 0 && lnText[0] === '-') { baseFromLine = baseFromLine + 1; fromLine = baseFromLine.toString(); fromLines.push(fromLine); ++nbDeletions; diffFromStr += (lnText + '\n'); ++linesOffset; // line added in the from file } else if (lnText.length > 0 && lnText[0] === '+') { baseToLine = baseToLine + 1; toLine = baseToLine.toString(); toLines.push(toLine); ++nbAdditions; diffToStr += (lnText + '\n'); --linesOffset; // line present in both files } else { baseFromLine = baseFromLine + 1; baseToLine = baseToLine + 1; fromLine = baseFromLine.toString(); toLine = baseToLine.toString(); for (let j = 0; j < Math.abs(linesOffset); ++j) { if (linesOffset > 0) { diffToStr += '\n'; toLines.push(''); } else { diffFromStr += '\n'; fromLines.push(''); } } linesOffset = 0; diffFromStr += (lnText + '\n'); diffToStr += (lnText + '\n'); toLines.push(toLine); fromLines.push(fromLine); } if (!baseFromLine) { fromLine = ''; } if (!baseToLine) { toLine = ''; } fromToLines[i] = [fromLine, toLine]; maxNumberChars = Math.max(maxNumberChars, fromLine.length); maxNumberChars = Math.max(maxNumberChars, toLine.length); }); // set side-by-side diffs text $(`#${diffId}-from`).text(diffFromStr); $(`#${diffId}-to`).text(diffToStr); // code highlighting for side-by-side diffs $(`#${diffId}-from, #${diffId}-to`).each((i, block) => { hljs.highlightBlock(block); hljs.lineNumbersBlock(block); }); // hljs.lineNumbersBlock is asynchronous so we have to postpone our // next treatments by adding it at the end of the current js events queue setTimeout(() => { // diff highlighting for added/removed lines on top of code highlighting $(`.${diffId} .hljs-ln-numbers`).each((i, lnElt) => { let lnText = lnElt.nextSibling.innerText; let linesInfo = linesInfoRegExp.exec(lnText); if (linesInfo) { $(lnElt).parent().addClass('swh-diff-lines-info'); let linesInfoText = $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').text(); $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').children().remove(); $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').text(''); $(lnElt).parent().find('.hljs-ln-code .hljs-ln-line').append(`${linesInfoText}`); } else if (lnText.length > 0 && lnText[0] === '-') { $(lnElt).parent().addClass('swh-diff-removed-line'); } else if (lnText.length > 0 && lnText[0] === '+') { $(lnElt).parent().addClass('swh-diff-added-line'); } }); // set line numbers for unified diff $(`#${diffId} .hljs-ln-numbers`).each((i, lnElt) => { - $(lnElt).children().attr('data-line-number', + $(lnElt).children().attr( + 'data-line-number', formatDiffLineNumbers(fromToLines[i][0], fromToLines[i][1], - maxNumberChars)); + maxNumberChars)); }); // set line numbers for the from side-by-side diff $(`#${diffId}-from .hljs-ln-numbers`).each((i, lnElt) => { - $(lnElt).children().attr('data-line-number', + $(lnElt).children().attr( + 'data-line-number', formatDiffLineNumbers(fromLines[i], null, - maxNumberChars)); + maxNumberChars)); }); // set line numbers for the to side-by-side diff $(`#${diffId}-to .hljs-ln-numbers`).each((i, lnElt) => { - $(lnElt).children().attr('data-line-number', + $(lnElt).children().attr( + 'data-line-number', formatDiffLineNumbers(null, toLines[i], - maxNumberChars)); + maxNumberChars)); }); // last processing: // - remove the '+' and '-' at the beginning of the diff lines // from code highlighting // - add the "no new line at end of file marker" if needed $(`.${diffId} .hljs-ln-line`).each((i, lnElt) => { if (lnElt.firstChild) { if (lnElt.firstChild.nodeName !== '#text') { let lineText = lnElt.firstChild.innerHTML; if (lineText[0] === '-' || lineText[0] === '+') { lnElt.firstChild.innerHTML = lineText.substr(1); let newTextNode = document.createTextNode(lineText[0]); $(lnElt).prepend(newTextNode); } } $(lnElt).contents().filter((i, elt) => { return elt.nodeType === 3; // Node.TEXT_NODE }).each((i, textNode) => { let swhNoNewLineMarker = '[swh-no-nl-marker]'; if (textNode.textContent.indexOf(swhNoNewLineMarker) !== -1) { textNode.textContent = textNode.textContent.replace(swhNoNewLineMarker, ''); $(lnElt).append($(noNewLineMarker)); } }); } }); // hide the diff mode switch button in case of not generated diffs if (data.diff_str.indexOf('Diffs are not generated for non textual content') !== 0) { $(`#panel_${diffId} .diff-styles`).css('visibility', 'visible'); } setDiffVisible(diffId); }); }); } }); } function setDiffVisible(diffId) { // set the unified diff visible by default $(`#${diffId}-loading`).css('display', 'none'); $(`#${diffId}-highlightjs`).css('display', 'block'); // update displayed counters $('#swh-revision-lines-added').text(`${nbAdditions} additions`); $('#swh-revision-lines-deleted').text(`${nbDeletions} deletions`); $('#swh-nb-diffs-computed').text(nbDiffsComputed); // refresh the waypoints triggering diffs computation as // the DOM layout has been updated Waypoint.refreshAll(); } // to compute all visible diffs in the viewport function computeVisibleDiffs() { $('.swh-file-diff-panel').each((i, elt) => { if (isInViewport(elt)) { let diffId = elt.id.replace('panel_', ''); computeDiff(diffsUrls[diffId], diffId); } }); } function genDiffPanel(diffData) { let diffPanelTitle = diffData.path; if (diffData.type === 'rename') { diffPanelTitle = `${diffData.from_path} → ${diffData.to_path}`; } let diffPanelHtml = `
${diffPanelTitle}
View file
`; return diffPanelHtml; } // setup waypoints to request diffs computation on the fly while scrolling function setupWaypoints() { for (let i = 0; i < changes.length; ++i) { let diffData = changes[i]; // create a waypoint that will trigger diff computation when // the top of the diff panel hits the bottom of the viewport $(`#panel_${diffData.id}`).waypoint({ handler: function() { if (isInViewport(this.element)) { let diffId = this.element.id.replace('panel_', ''); computeDiff(diffsUrls[diffId], diffId); this.destroy(); } }, offset: '100%' }); // create a waypoint that will trigger diff computation when // the bottom of the diff panel hits the top of the viewport $(`#panel_${diffData.id}`).waypoint({ handler: function() { if (isInViewport(this.element)) { let diffId = this.element.id.replace('panel_', ''); computeDiff(diffsUrls[diffId], diffId); this.destroy(); } }, offset: function() { return -$(this.element).height(); } }); } Waypoint.refreshAll(); } // callback to switch from side-by-side diff to unified one export function showUnifiedDiff(event, diffId) { $(`#${diffId}-splitted-diff`).css('display', 'none'); $(`#${diffId}-unified-diff`).css('display', 'block'); } // callback to switch from unified diff to side-by-side one export function showSplittedDiff(event, diffId) { $(`#${diffId}-unified-diff`).css('display', 'none'); $(`#${diffId}-splitted-diff`).css('display', 'block'); } // callback when the user clicks on the 'Compute all diffs' button export function computeAllDiffs(event) { $(event.currentTarget).addClass('active'); for (let diffId in diffsUrls) { if (diffsUrls.hasOwnProperty(diffId)) { computeDiff(diffsUrls[diffId], diffId); } } event.stopPropagation(); } export async function initRevisionDiff(revisionMessageBody, diffRevisionUrl) { await import(/* webpackChunkName: "highlightjs" */ 'utils/highlightjs'); // callback when the 'Changes' tab is activated $(document).on('shown.bs.tab', 'a[data-toggle="tab"]', e => { if (e.currentTarget.text.trim() === 'Changes') { $('#readme-panel').css('display', 'none'); if (changes) { return; } // request computation of revision file changes list // when navigating to the 'Changes' tab and add diff panels // to the DOM when receiving the result fetch(diffRevisionUrl) .then(response => response.json()) .then(data => { changes = data.changes; nbChangedFiles = data.total_nb_changes; let changedFilesText = `${nbChangedFiles} changed file`; if (nbChangedFiles !== 1) { changedFilesText += 's'; } $('#swh-revision-changed-files').text(changedFilesText); $('#swh-total-nb-diffs').text(changes.length); $('#swh-revision-changes-list pre')[0].innerHTML = data.changes_msg; $('#swh-revision-changes-loading').css('display', 'none'); $('#swh-revision-changes-list pre').css('display', 'block'); $('#swh-compute-all-diffs').css('visibility', 'visible'); $('#swh-revision-changes-list').removeClass('in'); if (nbChangedFiles > changes.length) { $('#swh-too-large-revision-diff').css('display', 'block'); $('#swh-nb-loaded-diffs').text(changes.length); } for (let i = 0; i < changes.length; ++i) { let diffData = changes[i]; diffsUrls[diffData.id] = diffData.diff_url; $('#swh-revision-diffs').append(genDiffPanel(diffData)); } setupWaypoints(); computeVisibleDiffs(); }); } else if (e.currentTarget.text.trim() === 'Files') { $('#readme-panel').css('display', 'block'); } }); $(document).ready(() => { if (revisionMessageBody.length > 0) { $('#swh-revision-message').addClass('in'); } else { $('#swh-collapse-revision-message').attr('data-toggle', ''); } let $root = $('html, body'); // callback when the user requests to scroll on a specific diff or back to top $('#swh-revision-changes-list a[href^="#"], #back-to-top a[href^="#"]').click(e => { let href = $.attr(e.currentTarget, 'href'); // disable waypoints while scrolling as we do not want to // launch computation of diffs the user is not interested in // (file changes list can be large) Waypoint.disableAll(); $root.animate( { scrollTop: $(href).offset().top }, { duration: 500, complete: () => { window.location.hash = href; // enable waypoints back after scrolling Waypoint.enableAll(); // compute diffs visible in the viewport computeVisibleDiffs(); } }); return false; }); }); }